Skip to content

cuslide2: add pyramidal OME-TIFF semantics and nvImageCodec 0.9 readiness - #1122

Open
cdinea wants to merge 17 commits into
release/26.08from
feature/cuslide2-ome-nvimgcodec09
Open

cuslide2: add pyramidal OME-TIFF semantics and nvImageCodec 0.9 readiness#1122
cdinea wants to merge 17 commits into
release/26.08from
feature/cuslide2-ome-nvimgcodec09

Conversation

@cdinea

@cdinea cdinea commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

What this PR changes

  • Add decode correctness updates for multiplex OME-TIFF (dynamic GRAY/RGB and UINT8/UINT16 selection)
  • Add dedicated OME-XML parser module (ome_xml.h/.cpp)
  • Add multi-file OME fileset handling using TiffData companion-file references
  • Use OME channel names and physical spacing metadata where available
  • Relax RGB-only optimization guards in TIFF/IFD read paths
  • Add OME parser unit tests
  • Update nvImageCodec integration for 0.9.0 compatibility (version header + SONAME discovery order)
  • Report TIFF resolution from tags 282/283/296 instead of hardcoded defaults, including RATIONAL decoding
  • Preserve pyramid sub-resolution levels on OME files whose TiffData only references full-resolution planes
  • Return an all-background raster for regions that do not intersect the image, matching cuslide
  • Stop destroying the nvImageCodec decoder at process exit, which intermittently aborted in free()

Scope notes

  • Explicit multiplex selector API expansion is intentionally out of scope for this PR.

Validation

  • Built cucim.kit.cuslide2 successfully in the prepared build environment
  • Added OME parser tests and integrated into cuslide2_tests
  • pre-commit run --hook-stage manual --all-files passes
  • Unit tests and the pyramidal OME-TIFF script pass as shown below

Running the pyramidal OME-TIFF script

# Build the plugin
export CUDACXX=$CONDA_PREFIX/pkgs/cuda-toolkit/bin/nvcc
./run build_local all release $CONDA_PREFIX
python -m pip install --editable python/cucim

# Run the script against an OME-TIFF
export ENABLE_CUSLIDE2=1
python scripts/test_pyramidal_ome_tiff.py <path-to>/retina_large.ome.tiff --no-cache

The script needs no GPU-specific flags: it decodes each region on both CPU and
GPU and compares the results, so a GPU is required for the comparison checks to
run rather than be skipped. Pass --no-cache to exercise the direct decode path
instead of the tile cache.

If you rebuild the C++ but see unchanged behavior under pytest, note that the
Python package loads the plugin from python/cucim/src/cucim/clara/, not from
cpp/plugins/cucim.kit.cuslide2/build-release/lib/. Re-running
./run build_local refreshes that copy.

Expected output, multi-plane OME-TIFF

retina_large.ome.tiff, 2 resolution levels, SizeC=2, SizeZ=64, uint16:

🧪 Synthetic uint16 decode checks
  ✅ Metadata dtype is uint16 (bits=16, typestr=<u2)
  ✅ Level-0 uint16 patch matches source ((10, 10), dtype=uint16)
  ✅ High-bit values preserved (max=12345)
  ✅ Level-1 uint16 plane matches source ((64, 64), dtype=uint16)
  ✅ Synthetic uint16 decode path validated
======================================================================
🔬 Testing pyramidal OME-TIFF with cuslide2
======================================================================
📊 Image summary
  Shape: [1567, 2048, 2]
  Dtype: <cucim.clara.DLDataType code:DLUInt(1) bits:16 lanes:1>
  Levels: 2
  Level 0: 2048x1567 (downsample: 1.000x)
  Level 1: 256x195 (downsample: 8.018x)
  ✅ Input reported as uint16 — decode checks will enforce dtype
🧬 OME metadata checks
  ✅ Found OME metadata block
  size_c=2, size_z=64, size_t=1
  channel_names_count=2
📐 Pyramid geometry checks
  ✅ Pyramid dims are non-increasing and match reported downsamples (2 distinct resolution(s) over 2 level(s))
🧪 Multi-level decode checks
  ✅ GPU and CPU level 0 decode are identical
  ✅ GPU and CPU level 1 decode are identical
🎯 Plane selection checks (C/Z/T kwargs)
    ✅ GPU and CPU plane decode are identical      # C=0,Z=0,T=0
    ✅ GPU and CPU plane decode are identical      # C=1,Z=0,T=0
🔄 Batch decode validation
  ✅ Batch decode GPU and CPU are identical
✅ Pyramidal OME-TIFF test completed

Two details worth checking in that output. Levels: 2 confirms the pyramid
sub-resolution survives: OME TiffData entries reference only the
full-resolution planes, so mapping planes alone collapses the list to level 0 and
makes level 1 unreachable through read_region(). And the run must end at
✅ Pyramidal OME-TIFF test completed with exit status 0; a trailing
free(): invalid pointer / Aborted was the exit-time teardown crash fixed here,
which reproduced on roughly one run in five.

Expected output, non-OME pyramidal TIFF

The same script accepts ordinary WSIs. Against CMU-1.tif the OME and plane
checks report as skipped, which is correct for a file with no OME-XML:

  Levels: 7
  Level 0: 46592x33792 (downsample: 1.000x)
  Level 6: 624x432 (downsample: 76.444x)
🧬 OME metadata checks
  ⚠️  OME metadata block not found in img.metadata
  ✅ Pyramid dims are non-increasing and match reported downsamples (7 distinct resolution(s) over 7 level(s))
  ✅ GPU and CPU level 0 decode are identical
  ✅ GPU and CPU level 6 decode are identical
⚠️  Skipping C/Z/T plane checks (size_c not available)
  ✅ Batch decode GPU and CPU are identical
✅ Pyramidal OME-TIFF test completed

Note that per-axis downsample ratios differ on real WSIs, so the geometry check
compares against the mean of the axis ratios rather than assuming a single factor.

Unit tests

cd python/cucim
ENABLE_CUSLIDE2=1 python -m pytest tests/unit/clara/test_tiff_read_region.py -q
..........................                                               [100%]
26 passed

All 26 pass with cuslide2 enabled and with the default plugin. The three
test_tiff_stripe_outside cases previously failed under cuslide2 with
Failed to decode IFD[0] with nvImageCodec. ROI: (-48,-40) 16x16: nvImageCodec
fills out-of-bounds pixels for a partially overlapping region, which is why
boundary reads passed, but rejects a region that does not intersect the image at
all.

@cdinea
cdinea requested review from a team as code owners July 22, 2026 05:18
@copy-pr-bot

copy-pr-bot Bot commented Jul 22, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@grlee77 grlee77 added improvement Improves an existing functionality non-breaking Introduces a non-breaking change labels Jul 24, 2026
@grlee77 grlee77 added this to cucim Jul 24, 2026
@grlee77 grlee77 added this to the v26.08.00 milestone Jul 24, 2026
@grlee77
grlee77 changed the base branch from main to release/26.08 July 24, 2026 20:07
@grlee77
grlee77 requested review from a team as code owners July 24, 2026 20:07
@grlee77
grlee77 requested a review from KyleFromNVIDIA July 24, 2026 20:07
Comment thread VERSION Outdated
@cdinea
cdinea force-pushed the feature/cuslide2-ome-nvimgcodec09 branch from 854582c to 70e0e73 Compare July 27, 2026 16:51
Update nvImageCodec dependency constraints to 0.9.x, add parser/decoder compatibility fallbacks for 0.9 runtime behavior, and relax legacy SubfileType assumptions so stacked OME-style TIFF pages load successfully in cuslide2 validation.
@jakirkham

Copy link
Copy Markdown
Member

/ok to test 57aa615

@gigony gigony left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @cdinea for adding ome-tiff support!
Overall, it looks good to me.

exec_params.device_allocator = nullptr;
exec_params.pinned_allocator = nullptr;
exec_params.max_num_cpu_threads = compute_max_decoder_threads();
exec_params.max_num_cpu_threads = 0;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering why we're setting this to 0 now.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah this would recover the overcontention issues which were fixed in the last release.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — unintentional, and Lev is right that it reintroduces the over-contention regression.

nvimgcodec.h documents max_num_cpu_threads = 0 as "default value equal to number of cpu cores", so this decoder was silently taking a full-core CPU threadpool. That was harmless when it was CPU-only and metadata-only, but it now backs the primary decode path (NVIMGCODEC_DEVICE_CURRENT, all backends), so with N dataloader workers you get N x cores threads.

The 0 is a leftover from the pre-promotion shape of this code; the rebase dropped compute_max_decoder_threads() along with it, which is the ~76-line shift in this hunk.

Fixed in 9bd44b8 — heuristic is back to min(max(1, hardware_concurrency / 4), 8), still overridable via CUCIM_MAX_DECODER_THREADS (>0 = exact count, 0 = explicit opt-in to the nvImageCodec default).

Comment on lines +592 to +596
// Current limitation (nvImageCodec v0.6.0):
// - codec_name returns "tiff" (container format) not compression type
// - Individual TIFF tags not exposed through metadata API
// - Only vendor-specific metadata blobs available (MED_APERIO, MED_PHILIPS, etc.)
//

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am wondering if nvImageCodec v0.9 still has these limitation.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We do support generic tags in the API, here is python example: https://docs.nvidia.com/cuda/nvimagecodec/samples/metadata.html#Generic-tiff-tag-reading
iirc this was already supported in 0.8? Lev can know more details on this and how to querry the tags

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has been supported since 0.8, but only with a GPU decoder.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, and thanks @mkepa-nv for the pointer — the comment was stale. Individual TIFF tags are queried unconditionally via NVIMGCODEC_METADATA_KIND_TIFF_TAG in extract_tiff_tags(), which runs on the line directly above, so the claim contradicted the adjacent code.

Removed in 9bd44b8. I kept only the part that's still true: codec_name reports the container format rather than the compression type, so compression is inferred from the tags below.

Comment on lines +934 to +940
// WORKAROUND for nvImageCodec 0.6.0: Philips TIFF metadata limitation
// ========================================================================
// nvImageCodec 0.6.0 does NOT expose:
// 1. Individual TIFF tags (SOFTWARE, ImageDescription, etc.)
// 2. Philips format detection for some files
//

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this still specific to nvImageCodec v0.9.0, or does the code also work with nvImageCodec v0.6.0?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That block was a stale v0.6.0 workaround note describing limitations that no longer apply — removed entirely in 9bd44b8. The surrounding code path is not v0.6.0-compatible anyway, since it depends on the 0.7.0+ TIFF tag metadata API.

@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: Copyright (c) 2020-2026, NVIDIA CORPORATION.
* SPDX-FileCopyrightText: Copyright (c) 2020-2021, NVIDIA CORPORATION.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* SPDX-FileCopyrightText: Copyright (c) 2020-2021, NVIDIA CORPORATION.
* SPDX-FileCopyrightText: Copyright (c) 2020-2026, NVIDIA CORPORATION.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. This one had actually gone backwards — the base was already 2020-2026 and the branch reverted it to 2021

Comment thread cpp/cmake/deps/nvimgcodec.cmake Outdated
if (NOT TARGET deps::nvimgcodec)
set(NVIMGCODEC_SONAME_CANDIDATES
"libnvimgcodec.so.0.9.0"
"libnvimgcodec.so.0.8.0"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the conda yaml file you set requirements >= 0.9 for nvimagecodec. Why do we need 0.8 here then?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, that 0.8.0 line shouldn't be there — it's left over from when this branch still supported 0.8. Everything else already asks for 0.9: dependencies.yaml, the conda environment files, conda_build_config.yaml and pyproject.toml are all >=0.9.0,<0.10.0, and the vendored headers say NVIMGCODEC_VER_MINOR 9. Dropped it from this file and the cuslide2 copy.

Digging into it though, removing that line isn't quite enough. The list still ends with libnvimgcodec.so.0 and libnvimgcodec.so, and those symlinks point at whatever major-0 build happens to be installed — so on a machine with only 0.8 we'd still pick it up without noticing. Combined with the two things you spotted elsewhere (limit_images gone in 0.9, no compile-time version checks), that would blow up at runtime instead of failing the build, which is a much worse way to find out.

So I added a check at configure time: it reads NVIMGCODEC_VER_MAJOR/MINOR out of the nvimgcodec_version.h we discover, errors below 0.9, warns if it's outside the pinned range, and skips quietly when the header isn't there so wheel and system installs keep working. The find_package branch now looks at nvimgcodec_VERSION too — it wasn't checking anything at all before.

On consolidating the two nvimgcodec.cmake files: fair point, and the check I just added is now duplicated as well. I'd rather do that separately than grow this PR — the two files get picked up through different superbuild_depend() search paths (cuslide2 via SUPERBUILD_ADDITIONAL_DEPS_DIRS, the other via CMAKE_SUPERBUILD_DEPS_ROOT_DIR, which points at the cuslide plugin's cmake dir and has no nvimgcodec entry), so merging them takes a little care not to bbreak standalone plugin builds.


set(NVIMGCODEC_SONAME_CANDIDATES
"libnvimgcodec.so.0.9.0"
"libnvimgcodec.so.0.8.0"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar question as above. Also maybe the cmake structure can be simplified so nvimagecodec search is done in a single place? But this is a question for a future pull request

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as on the other cmake file — 0.8.0 is gone in 0912636, with a configure-time >=0.9.0 check added.

On consolidating the two nvimgcodec.cmake files: agreed it's worth doing, but I'd rather keep that as a follow-up. They're reached through different superbuild_depend() search paths, so merging them cleanly without breaking the standalone plugin build is a bit more than this PR should take on.

nvimgcodecCodeStreamView_t view{};
view.struct_type = NVIMGCODEC_STRUCTURE_TYPE_CODE_STREAM_VIEW;
view.struct_size = sizeof(nvimgcodecCodeStreamView_t);
view.struct_size = offsetof(nvimgcodecCodeStreamView_t, limit_images);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't look right. Why would we ask for offset of struct member instead of the whole struct size? And also limit_images was removed in 0.9 so I have no idea how is this working right now

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right on both counts. struct_size should be sizeof(nvimgcodecCodeStreamView_t) — the offsetof(..., limit_images) was an old ABI shrink so pre-limit_images libraries wouldn't read past their known layout. That doesn't make sense in a 0.9-only PR, and limit_images is gone in the real 0.9 headers anyway.

Switched all four sites (three in nvimgcodec_decoder.cpp, one in nvimgcodec_tiff_parser.cpp) back to sizeof, and dropped the limit_images = 0 assignment plus the ABI comment.

Why it still compiled: the vendored nvimgcodec.h in this tree still had limit_images even though it claimed 0.9. That lines up with your other comment about updating to the full new headers — done in ce00b56 (headers swapped from a real 0.9.0 install, and the plugin builds cleanly against them).

uint32_t num_channels = ifd_info.num_channels > 0 ? ifd_info.num_channels : 3;
size_t row_stride = width * num_channels;
uint32_t num_channels = decode_spec.num_channels;
size_t row_stride = width * num_channels * decode_spec.bytes_per_sample;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can overflow for large width, I would static cast widt to size_t if you store it in that type anyway

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d7c0023width, num_channels, and bytes_per_sample are all uint32_t, so the product was wrapping in 32-bit before the assignment to size_t. Cast width to size_t so the multiply happens in 64-bit.

size_t row_stride = region.width * num_channels;
const DecodePixelSpec decode_spec = resolve_decode_pixel_spec(ifd_info);
uint32_t num_channels = decode_spec.num_channels;
size_t row_stride = region.width * num_channels * decode_spec.bytes_per_sample;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same overflow can happen here

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same fix applied here in d7c0023, plus the matching ssite in schedule_batch_decode.

ifd->height_ = height_l0 / downsample;
}
// Fix width and height of IFD
ifd->width_ = width_l0 / downsample;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what if downsample is 0?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1328a76 — that downsample > 0 guard was already present and got dropped when this block moved during the OME refactor. Restored it

//

// 34 is from `Attribute Name="PIM_DP_IMAGE_DATA"`
char* data_ptr = const_cast<char*>(image_desc_cstr) + node_offset + 34;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if this is outside of the image_desc_cstr?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in bafbdb5. You're right that it could be — node_offset was only checked for >= 0, never against the length, so the + 34 could put us past the terminator, and both loop stop conditions (> and \0) are byte values inside the buffer, so the scan would just walk off the end of the string allocation.

This turned out to be a revert rather than a new bug: the bounds checks existed before this branch and got lost in a rebase. Restored the length check

auto add_owned_channel_name = [&resource, &channel_names](const std::string& name) {
void* raw = resource.allocate(name.size() + 1, alignof(char));
auto* buf = static_cast<char*>(raw);
memcpy(buf, name.c_str(), name.size() + 1);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would just make the channel_names to store string instead of the string_view. That vector is the owner of the names, so this even sounds like a more correct approach.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in eac5aee — channel names are now built in an owning std::vector<std::string>.

We still convert to std::pmr::vector<std::string_view> at the end because ImageMetadata::channel_names takes that type and may retain pointers into the metadata buffer. With _GLIBCXX_USE_CXX11_ABI=0 we also can't use std::pmr::string, so the one-shot copy into resource is what keeps the views alive for the metada

gpu_time = time.time() - start
gpu_np = _to_numpy(gpu_region)
print(f" GPU level {level}: {gpu_np.shape}, {gpu_np.dtype}, {gpu_time:.4f}s")
if gpu_np.shape != cpu_np.shape:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shape check is not enough. I would check the pixels too. And maybe you should check that each level have a correct dimensions (that they decrease with correct ratio for each level done) - otherwise you will not be sure that parsing is working as expected.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 84fe4ed. Multi-level GPU/CPU reads now also do np.array_equal (with a max-diff error on mismatch), and _validate_pyramid_geometry asserts that each level shrinks, downsamples increase, and dims match base / ds within ±1 for rounding.let me know if further testign is needed

print(f" ⚠️ GPU plane validation skipped/failed: {e}")


def _validate_batch_decode(img, level_dims):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You added a bunch of code for higher resolution decode (with dtype uint 16) - I would add test for such case as well

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 15752d3. Added a synthetic uint16 pyramidal OME-TIFF decode check that always runs: it asserts metadata/region dtype stays uint16, preserves values >255 (vso we didn't silently narrow to uint8), and matches source pixels on level 0 and level 1. When the caller-provided file is already uint16, the multi-level CPU/GPU checks now enforce that dtype too.

cdinea added 3 commits July 29, 2026 12:26
max_num_cpu_threads = 0 requests one CPU thread per core from
nvImageCodec's default executor. That was harmless when this decoder was
CPU-only and used solely for metadata extraction, but it now backs the
primary decode path (NVIMGCODEC_DEVICE_CURRENT, all backends enabled),
so every worker process allocated a full-core threadpool and multi-worker
runs regressed into the thread over-contention this heuristic was
originally added to prevent.

Restore compute_max_decoder_threads(), which bounds the pool to
min(max(1, hardware_concurrency / 4), 8) and stays overridable via
CUCIM_MAX_DECODER_THREADS.

Also drop the stale nvImageCodec v0.6.0 limitation comments: individual
TIFF tags are queried unconditionally through
NVIMGCODEC_METADATA_KIND_TIFF_TAG by extract_tiff_tags(), so the claim
that they are unavailable contradicted the adjacent code.
Replace the checked-in nvimgcodec.h / nvimgcodec_version.h copies with
the real 0.9.0 package headers. The previous vendored files claimed 0.9
but still described the pre-0.9 CodeStreamView layout that included
limit_images, so call sites could compile against an API the linked
library no longer provides.

With the real headers in place, restore struct_size to
sizeof(nvimgcodecCodeStreamView_t) at all four CodeStreamView sites and
drop the leftover limit_images assignment and offsetof ABI shrink. Also
bump SPDX copyright years on files touched by this PR.
@jakirkham

Copy link
Copy Markdown
Member

/ok to test ce00b56

cdinea added 4 commits July 29, 2026 14:33
width, num_channels and bytes_per_sample are all uint32_t, so the row
stride product was evaluated in 32-bit and could wrap before being
widened on assignment to size_t. Cast the width operand to size_t so
the multiply happens in 64-bit, at all three decode sites.
The unconditional `+ 34` skip past the PIM_DP_IMAGE_DATA node could land
beyond the terminator, leaving the following scan loops unanchored since
both stop conditions are byte values inside the buffer. Restore the
length check on node_offset and the explicit desc_end sentinel so every
loop is bounded by the string, and drop the skip and the const_cast.
Replace the allocate-and-view lambda with an owning vector of strings.
ImageMetadata::channel_names still takes string_views and may retain
pointers into the metadata buffer, so copy into the resource once at
the end when constructing the view vector the API requires.
@jakirkham

Copy link
Copy Markdown
Member

/ok to test eac5aee

Add pixel-level CPU/GPU equality checks on multi-level reads, and assert
pyramid dimensions shrink and match the reported downsample factors so
parsing mistakes cannot pass on shape alone.
Bump SPDX copyright years, normalize trailing newlines, convert the
vendored nvImageCodec headers to the SPDX-only form the copyright hook
expects, and ignore upstream typos (minize/shrinked) in those headers.
@jakirkham

Copy link
Copy Markdown
Member

/ok to test aef0ca1

Add a synthetic uint16 OME-TIFF decode path that asserts metadata and
region dtypes stay 16-bit, preserves values above 255, and matches
source pixels across pyramid levels. Also enforce uint16 on multi-level
CPU/GPU checks when the input file reports that dtype.
@jakirkham

Copy link
Copy Markdown
Member

/ok to test 15752d3

The geometry check assumed a single-plane WSI pyramid: strictly increasing
downsample per level, and level dims recoverable as base / downsample.
Both assumptions are false in practice.

cuslide2 reports downsample as the mean of the per-axis ratios
((w0/wi) + (h0/hi)) / 2, and levels may use different X and Y ratios, so
dims cannot be derived from the scalar. Multi-plane OME files also expose
one IFD per C/Z/T plane, so many levels legitimately share a resolution.

Validate what cuslide2 actually guarantees instead: dims are
non-increasing, downsample strictly increases when the resolution changes
and is identical when a resolution repeats, and the reported scalar
matches the mean axis ratio. Repeated resolutions now warn that the file
looks multi-plane rather than failing the run.
@jakirkham

Copy link
Copy Markdown
Member

/ok to test aeca33b

cdinea added 3 commits July 29, 2026 17:58
Resolution was hardcoded to 1.0 with no unit, so every file reported
meaningless pixel spacing. Query tags 282/283/296, decode RATIONAL
values, and populate the IFD fields from them.

OME TiffData entries only reference full-resolution planes, so mapping
planes alone collapsed the level list to level 0 and made pyramid
sub-resolutions unreachable through read_region(). Backfill the levels
the plane index does not cover.

Also surface two previously silent failures: tag retrieval returning
nothing (which disables OME/vendor detection) and unusable OME XML.
Fix the test helper so device-resident results route through CuPy
instead of degrading every GPU comparison to a skipped check.
Reading a multi-plane OME-TIFF aborted with "free(): invalid pointer"
after the run completed, roughly one exit in five.

The parser manager destroyed its nvImageCodec decoder at process exit.
That cannot be ordered against the CUDA driver's own teardown, so
nvimgcodecDecoderDestroy intermittently freed pointers the driver had
already reclaimed. Neither available ordering was safe: the singleton's
static destructor runs before the atexit handler registered in the
constructor, because that handler is registered first and exit handlers
run in reverse order, and moving teardown to the handler still aborted.

Leak the singleton and skip exit-time teardown. These are process-
lifetime handles, so the driver and OS reclaim them. shutdown() stays
available for deterministic release while CUDA is up.
read_region() threw "Failed to decode IFD[0] with nvImageCodec" for a
region with no overlap with the image, where the documented behavior and
the cuslide behavior is an all-zero raster of the requested size. This
failed test_tiff_stripe_outside for all three compression variants.

nvImageCodec fills out-of-bounds pixels when a region partially overlaps,
which is why boundary reads worked, but it rejects a region that does not
intersect the image at all. Detect that case and return a zeroed raster
without consulting the decoder, for host and device outputs alike.
@cdinea

cdinea commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

/ok to test 729d27a


// Fallback: file extension heuristics when COMPRESSION tag is not present in file
// Fallback: file extension heuristics when COMPRESSION tag is not available
// (either nvImageCodec < 0.7.0 or tag not present in file)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is comment still relevant? As far as I understand it is not possible to have nvImageCodec < 0.7.0 now

@jakirkham

Copy link
Copy Markdown
Member

/ok to test 729d27a

Packaging now pins nvImageCodec to >=0.9.0,<0.10.0, so comments that
gate behavior on 0.6.0, 0.7.0, or 0.8.0 no longer describe anything
reachable. Reword them to say what the code does rather than which
release introduced it, keeping the vendor-detection fallbacks: those
still matter for files that carry no SOFTWARE tag or ImageDescription,
independently of the codec version.

Also remove CUSLIDE2_NVIMGCODEC_HAS_TIFF_TAG_METADATA. It was defined
unconditionally to 1 with no alternate branch, so the surrounding
preprocessor conditionals were always taken.
@cdinea

cdinea commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

/ok to test e011f44

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

improvement Improves an existing functionality non-breaking Introduces a non-breaking change

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

8 participants